在 Next.js 框架中使用 SSG 或 SSR 時,使用者首次進到畫面時看到的是伺服器產生的 HTML,但是這個 HTML 只是一個靜態的頁面,也就是說他只是呈現了給眼睛看到的畫面,並沒有實際的功能。如果要讓它能真的「動起來」,像是讓按鈕點擊能有效果、動畫可以動、資料可以更新等,就需要 Hydration 的這個步驟。
今天就讓我們就來深入認識這個讓頁面活起來的關鍵步驟 - Hydration。
首先一樣先來看看何謂「Hydration」?如果去查這個英文單字的意思是的話,可以發現到這個英文單字的原意是「水合」或「補水」的過程,而在 React 世界中,這個詞彙就延伸成「注入互動能力的過程」,也就是讓原本靜態的頁面具備互動性。白話一點來說明的話,也就是「讓伺服器產生的 HTML 頁面,和負責互動操作的 JavaScript 程式邏輯結合起來的過程」。
這裡需要強調的是 Hydration 是 React 本身就具備的機制,並不是 Next.js 這個框架才提供的功能,Next.js 只是運用 React 的 Hydration 能力,讓伺服器預渲染出的 HTML,在交給瀏覽器時,可以進行綁定互動操作的邏輯等和 JavaScript 有關的內容。換句話說,也就是伺服器先產生 HTML,當瀏覽器拿到 HTML後,由 React 透過 hydration 的過程接管並啟動互動(包含建立 Fiber、掛事件、啟動 effect 等)。
那 Hydration 這個所謂「讓靜態頁面活起來」的過程實際上做了什麼樣的事情呢?我們先從起始點 hydrateRoot 開始看起。
我們接著來了解一下 Hydration 的過程實際進行了什麼動作。
我們已經知道 Hydration 這個過程會出現在讓靜態頁面能有互動操作的能力,也就是說 Hydration 會出現在 SSR 或 SSG 的渲染模式中,所以執行 Hydration 流程的時機點,也就會出現在瀏覽器取得完整的 HTML,並且載入 JavaScript 後。
當對伺服器發送 HTML request 之後,瀏覽器會收到頁面完整的 HTML,在這個時候,HTML 都還沒有被 client 端(也就是瀏覽器)接管這整個頁面的操作。等到 JavaScript 也載入後,才會開始 Hydration 的步驟,從這裡開始,也會由 React 開始進行相關的流程。
首先,React 會建立一個會進行 Hydration 的 Root,接著會進行以下的流程。
第一步:React 會解析 React component,產生 virtual DOM,並把這個 virtual DOM 拿去和伺服器 reponse 回來的 HTML 進行比較,如果沒有差異就會繼續進行下一步,如果有差異則通常會 fallback 成 CSR。
第二步:會先接管 DOM,將 virtual DOM 和實體的 DOM 關聯起來,並且綁定事件處理器,到這裡 Hydration 的主要過程就會告一個段落。接著就會進行 state、ref 的初始化,最後依序執行 useLayoutEffect)與 useEffect。全部完成,頁面才真正具備完整的互動能力。
接下來讓我們接著來看一下 Next.js 怎麼啟動 Hydration。
前幾篇我們有說過 Next.js 是基於 React 的框架,所以本體還是 React。但是 CSR 模式下,並不會有 Hydration 的過程,也不需要有 Hydration 的過程,因為在 CSR 模式下,並不會有取得靜態 HTML 的這個階段,自然也就不需要另外將事件綁定在 HTML 的步驟。
那在 Next.js 中的 SSR 模式下究竟是怎麼讓 React 進行 Hydration 的呢?
關鍵就在於在創建 root 的時候,使用的 function 不同。在 CSR 模式下,是使用 createRoot
來建立整個專案的 root,但是在 SSR 模式下,是使用 hydrateRoot
來建立專案的 root。
Next.js 會在 SSR/SSG 頁面使用 hydrateRoot 完成 Hydration;一般的 CSR 頁面則使用 createRoot 將畫面完整掛載上去,Next.js 會自動從兩者之中選用該使用的 function。
// 取得當前實體的 DOM
const domNode = document.getElementById('root');
// reactNode 是 virtualDOM
const root = hydrateRoot(domNode, reactNode);
近一步查看 hydrateRoot 的這個 fucntion 的原始碼,可以大概了解到進行 Hydration 的時候主要會有以下這幾個流程。
export function hydrateRoot(
container: Document | Element,
initialChildren: ReactNodeList,
options?: HydrateRootOptions,
): RootType {
// 步驟一、檢查傳入的 container 是否是一個正確的實體的 DOM
if (!isValidContainer(container)) {
throw new Error('Target container is not a DOM element.');
}
warnIfReactDOMContainerInDEV(container);
// 中間略
const concurrentUpdatesByDefaultOverride = false;
let isStrictMode = false;
let identifierPrefix = '';
let onUncaughtError = defaultOnUncaughtError;
let onCaughtError = defaultOnCaughtError;
let onRecoverableError = defaultOnRecoverableError;
let onDefaultTransitionIndicator = defaultOnDefaultTransitionIndicator;
let transitionCallbacks = null;
let formState = null;
// 步驟二、根據傳入的 option 內容來設定最終要套用的設置內容
if (options !== null && options !== undefined) {
if (options.unstable_strictMode === true) {
isStrictMode = true;
}
if (options.identifierPrefix !== undefined) {
identifierPrefix = options.identifierPrefix;
}
if (options.onUncaughtError !== undefined) {
onUncaughtError = options.onUncaughtError;
}
if (options.onCaughtError !== undefined) {
onCaughtError = options.onCaughtError;
}
// 中間略
}
// 步驟三、建立可進行 hydration 的 root
const root = createHydrationContainer(
initialChildren,
null,
container,
ConcurrentRoot,
hydrationCallbacks,
isStrictMode,
concurrentUpdatesByDefaultOverride,
identifierPrefix,
onUncaughtError,
onCaughtError,
onRecoverableError,
onDefaultTransitionIndicator,
transitionCallbacks,
formState,
);
// 步驟四、將 container 標記為 React 的 root
markContainerAsRoot(root.current, container);
// 步驟五、在容器上註冊委派事件的監聽器
listenToAllSupportedEvents(container);
// 步驟六、最後返回完成 hydration 實例
return new ReactDOMHydrationRoot(root);
}
在執行 hydrateRoot 的時候,我們會傳入一個實體的 DOM (伺服器回傳 HTML 後,瀏覽器解析出來的 DOM 節點) 和 React element tree(例如 ),以及額外的設定(例如:錯誤處理、Strict Mode 等設定內容),還有傳入初始的 children。
一開始會先檢查 container 是否為合法的 DOM 節點,接著解析並套用 options 中的設定。之後 React 會建立一個 hydration 專用的 virtual DOM,並將 container 標記為對應的 root,接著綁定事件委派監聽器,讓 Fiber node 可以進一步綁定事件並且啟用互動功能,最後會回傳一個 ReactDOMHydrationRoot 的實例。
在這個步驟先透過 hydrateRoot 把要進行 Hydration 的 root 實例準備好後,才能進一步與真正的 HTML 比對並且綁定事件,如果使用 createRoot 也就不會進行 Hydration。
雖然我們已經知道 Hydration 是讓靜態頁面具備互動能力的關鍵,但整個 Hydration 流程其實也伴隨著一些效能上的問題。特別是當一個頁面越來越複雜、互動元件越來越多時,那就會需要耗費比較多的時間,將事件完整地綁定到正個 React DOM tree 上。這個狀況也就會讓首次互動時間(TTI, Time to Interactive)變得越來越長,進而讓使用者感受到網頁延遲的狀況。
為了解決這個問題,React 引入了一種能優化這個狀況的 Hydration 策略,也就是「Selective Hydration」。
所謂的「Selective Hydration」是一種只對有需要的 DOM 節點進行 Hydration,並根據互動優先進行 Hydration 的機制。
「Selective Hydration」建立在 Concurrent React 這個背景機制的基礎上,在 Concurrent React 出現之前,在進行渲染 rendering 的時候,在完成渲染之前,都無法中斷渲染的動作。但在 Concurrent React 這個機制出現後,渲染這個動作就變成可以中斷,並且可以在需要重新開始的時候重新開始。也就是說「可以在需要 Hydration 的部分上優先進行 Hydration,避免整個畫面因為需要等到全部 Hydration 都完畢後才能操作,而造成的卡頓」,以此達到優化網頁的效能,以及使用者體驗的效果。
原本的流程是:
伺服器返回完整的 HTML → 載入 JavaScript → React 從上到下進行整個頁面的同步 Hydration → Hydration 完成,頁面才有互動的能力
Selective Hydration 之後流程是:
伺服器返回完整的 HTML → 載入 JavaScript → 先對使用者互動的區塊進行 Hydration 流程 → 空檔進行其他剩餘區塊的 Hydration
因為流程上的差異,使得 SSR 不僅可以很快可以看到完整的畫面,還可以很順暢地進行畫面的操作。
Hydration 雖然對於 SSR 來說,是一個很重要的過程,但是也是一個反而讓 SSR 效能變差的過程。因為在 SSR 的模式下,雖然已經由伺服器渲染出完整的 HTML,但是如果想要讓畫面可以正常操作,還是需要在瀏覽器上載入 JavaScript,並且進行 Hydration。即使 React 18 後,有支援 Selective Hydration,讓 Hydration 這個流程能以比較聰明的方式以優先度差異分批次進行,進而讓使用者體驗能變好,不過還是需要在瀏覽器上進行下載、解析、執行整個 JavaScript bundle,以及將其綁定到 DOM 事件上這些最耗費效能的流程,而這些過程卻有可能會導致首次互動時間的延遲。
因此,如果想要真正地解決 Hydration 所帶來的效能瓶頸,最直接的方向就是減少需要 Hydration 的 JavaScript 量。而在 Next.js 框架中,以這個方向下去實作的方式就是「使用 Server Components」。
這個時候可能會有人有疑問,「Server Component 為何可以避免不必要的 Hydration」?
原因就在於 Server Component 是一個把不需要操作邏輯的部份拆出來的元件,它的特性是只能用於純靜態的內容使用。當使用 React Server Component 來呈現一個純靜態的內容後,這部分的內容就不再需要經歷過沒有必要的 Hydration 過程,React Server Component 只會在 NNext.js 的 Web Server 端上被轉換成 RSC Payload,再於瀏覽器上被解析成 React Element Tree,進而轉為 Virtual DOM 更新畫面。
也就是說「Server Component」除了能讓大家養成將程式碼刻意拆小的良好習慣外,也能以這樣的方式有效地讓不需要 Hydration 的內容徹地的被分離出來,以達到解決 Hydration 所造成的效能問題。
我們已經知道在開發時能透過 React Server Component(RSC)來減少不必要的 Hydration 過程,因為 RSC 會完全在伺服器上執行、並與畫面相應的 RSC payload,讓瀏覽器將 payload 拿來產生畫面,且不會產生 JavaScript,也不會進行 Hydration。
但是這真的是最佳的解法嗎?
在開發一個完整的產品時,我想大家都知道實際上很多元件都會需要具備操作或互動的能力,不可能整個頁面只存在著純靜態、不含邏輯的元件。常見的按鈕點擊、表單輸入、狀態切換等功能,都需要透過事件綁定才能實現,而這類互動行為並無法僅靠 Server Component(RSC)完成,仍然必須使用 Client Component,並且經過 hydration 才能讓畫面具備互動能力。
所以就算使用了 RSC 減少部分 Hydration 負擔,依然還是會存在著 Hydration 的成本,尤其是當頁面中含有大量互動元件時,RSC 並無法完全解決效能瓶頸。在這樣的情境下,就還需要搭配更進一步的技術手段,才能真正優化使用者體驗。
因此,為了更全面的優化頁面的效能,React 與 Next.js 還提出了其他輔助機制,像是 Streaming 和 Partial Prerendering(PPR)。這兩項技術雖然切入角度不同,但都提供了對效能問題不同層面的處理方式。後面我們也會再來看看 Streaming 和 Partial Prerendering(PPR)。
明天我們會繼續延伸來看在處理 Hydration 時,會很常遇到的錯誤,也就是 Hydration mismatch 的部分。
官方文件 - hydrateRoot
官方文件 - React 18 and concurrent features
Selective Hydration